Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle redirects in apiserver proxy handler #34987

Merged
merged 1 commit into from
Nov 5, 2016

Conversation

timstclair
Copy link

@timstclair timstclair commented Oct 17, 2016

Overview:

  1. Peek at the HTTP response from the proxied backend
  2. If it is a redirect response (302/3), redo the request to the redirect location
  3. If it's not a redirect, forward the response to the client and then set up the proxy as before

This change is required for implementing streaming requests in the Container Runtime Interface (CRI). See design.

For #29579

/cc @yujuhong


This change is Reviewable

@timstclair timstclair added the release-note-none Denotes a PR that doesn't merit a release note. label Oct 17, 2016
@timstclair timstclair added this to the v1.5 milestone Oct 17, 2016
@k8s-github-robot k8s-github-robot added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Oct 17, 2016
@ncdc
Copy link
Member

ncdc commented Oct 24, 2016

I'll try to take a look at this later today or tomorrow. Also cc @liggitt

@liggitt
Copy link
Member

liggitt commented Oct 24, 2016

need to take a closer look, but at the very least, this should be opt-in... not all proxy handling should follow redirects server-side

@timstclair
Copy link
Author

at the very least, this should be opt-in... not all proxy handling should follow redirects server-side

Makes sense. Here's a couple options:

  1. Make it configurable in the proxy, e.g. configure the proxy for exec/attach/portforward to follow redirects
  2. Make it configurable per-request/response, e.g. a header specifies whether the proxy should follow the redirect
  3. I suppose the traditional solution from a reverse proxy perspective would be to not handle the redirect at all, and just rewrite the location header with the external address (i.e. /proxy/...)

I'm leaning towards option (1), since the apiserver can expect the redirect in that case. Option (3) could work, but it would add an extra hop to the connection (might be a non-issue?), and the apiserver would need to know if redirect location was a pod or a node.

Am I missing anything?

/cc @yujuhong

@liggitt
Copy link
Member

liggitt commented Oct 24, 2016

option 1, feature-gated with CRI-enablement, seems like the best approach. still want to dig into the mechanism (and ideally factor it out of the main flow a little better)

@yujuhong
Copy link
Contributor

option 1, feature-gated with CRI-enablement, seems like the best approach. still want to dig into the mechanism (and ideally factor it out of the main flow a little better)

Option 1 sounds good to me, too.

I don't think we need to feature gate this in the apiserver. CRI affects how kubelet integrates with the runtime, and should be feature gated there (on the kubelet side). This should not concern apiserver, and apiserver should not receive redirects (for exec/attach/portforward) at all if the feature is not enabled in kubelet.

@liggitt
Copy link
Member

liggitt commented Oct 24, 2016

I don't think we need to feature gate this in the apiserver

sniffing responses and redirecting is a pretty invasive change (as evidenced by this PR)... it should be gated to protect against issues found while the feature is in beta

@timstclair
Copy link
Author

I refactored this based on the discussion here. Redirect interception is now only enabled for exec/attach/port-forward requests, and can be disabled with a feature gate (StreamingProxyRedirects).

if err != nil {
return nil, err
}
removeCORSHeaders(resp)
return resp, nil
}

var _ = net.RoundTripperWrapper(&corsRemovingTransport{})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't drop this, let the compiler help us

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The compiler already enforces this since we use it in a net.RoundTripperWrapper context, and the net package name now conflicts with the stdlib net. If you feel strongly I can alias the package name and add it back.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not seeing the type assertion elsewhere, I'd like to keep it

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

)

var (
// Default values for recorded features. Every new feature gate should be
// represented here.
knownFeatures = map[string]featureSpec{
allAlphaGate: {false, alpha},
externalTrafficLocalOnly: {false, alpha},
externalTrafficLocalOnly: {true, beta},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just something weird about the diffing... I rebased on HEAD and this resolved.


backendConn, err := proxy.DialURL(h.Location, h.Transport)
if err != nil {
if err := h.handleUpgrade(w, req); err != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change moves the hijack and close of the request connection before we get a chance to respond to errors dialing the backend. That means we would no longer send API error responses in error cases.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! It turns out this is an existing issue, since the ErrorResponder fails once the connection is hijacked. I added a test case for this, and fixed it by writing the errors to the hijacked connection directly.

Copy link
Author

@timstclair timstclair left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reviewing! Responded to all comments.


backendConn, err := proxy.DialURL(h.Location, h.Transport)
if err != nil {
if err := h.handleUpgrade(w, req); err != nil {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! It turns out this is an existing issue, since the ErrorResponder fails once the connection is hijacked. I added a test case for this, and fixed it by writing the errors to the hijacked connection directly.

if err != nil {
return nil, err
}
removeCORSHeaders(resp)
return resp, nil
}

var _ = net.RoundTripperWrapper(&corsRemovingTransport{})
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The compiler already enforces this since we use it in a net.RoundTripperWrapper context, and the net package name now conflicts with the stdlib net. If you feel strongly I can alias the package name and add it back.

)

var (
// Default values for recorded features. Every new feature gate should be
// represented here.
knownFeatures = map[string]featureSpec{
allAlphaGate: {false, alpha},
externalTrafficLocalOnly: {false, alpha},
externalTrafficLocalOnly: {true, beta},
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just something weird about the diffing... I rebased on HEAD and this resolved.

@liggitt
Copy link
Member

liggitt commented Oct 28, 2016

It turns out this is an existing issue, since the ErrorResponder fails once the connection is hijacked. I added a test case for this, and fixed it by writing the errors to the hijacked connection directly.

The previous flow was intentional... let the error responder report errors during backend establishment, then hijack. The error responder can't write once we've written content to the connection (headers are already committed, and a well formed API error JSON blob after random other content isn't usable)

func (r *hijackedErrorResponder) Error(err error) {
header := http.Header{}
header.Set("Content-Type", "text/plain")
body := bytes.NewBufferString(err.Error())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't this JSON before (or maybe negotiated?)

@timstclair
Copy link
Author

The previous flow was intentional... let the error responder report errors during backend establishment, then hijack.

Hmm, I see. I'll refactor it a bit tomorrow wait until after the BE connection is established to hijack.

There was still an error in the old flow though, because the Responder was invoked after the connection was hijacked (here). I guess the hijacking should just be moved so that it's the last step before the proxy is established?

@liggitt
Copy link
Member

liggitt commented Oct 28, 2016

Yeah, the request construction block might be able to be moved above the hijack. Yeah, hijack at the last possible moment

@timstclair
Copy link
Author

Done. PTAL.

@liggitt
Copy link
Member

liggitt commented Oct 31, 2016

Will finish up review tomorrow

@yujuhong
Copy link
Contributor

yujuhong commented Nov 1, 2016

@liggitt @ncdc a friendly ping since code freeze is imminent!

if err != nil {
h.Responder.Error(err)
h.Responder.Error(fmt.Errorf("error dialing backend: %v", err))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depending on what causes connectBackend/connectBackendWithRedirects to return an error, you may end up writing error dialing backend: error dialing backend: .... It would be nice to avoid that if possible.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

return true
// Forward raw response bytes back to client.
if _, err = requestHijackedConn.Write(rawResponse); err != nil {
glog.Errorf("Error proxying response from backend to client: %v", err)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utilruntime.HandleError(fmt.Errorf("error proxying response...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (What does this give us? Should I update the other error logging in the go routines below too?)

return conn, fmt.Errorf("error dialing backend: %v", err)
}

if err = beReq.Write(conn); err != nil {
Copy link
Member

@liggitt liggitt Nov 1, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't this consume and close the original req.Body?

Copy link
Contributor

@sttts sttts Nov 3, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it proxies the original request's body to the backend. So that's the intention, isn't it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, but connectBackend() is called repeatedly with the same request in cases where the backend returns a redirect

return nil, nil, fmt.Errorf("too many redirects (%d)", redirects)
}

intermediateConn, err = h.connectBackend(req, location)
Copy link
Member

@liggitt liggitt Nov 1, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't calling connectBackend consume the req.Body the first time? won't that cause failures in subsequent calls (or in the final call when the CRI redirect destination doesn't get any body content?)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think so, but this is also consistent with what http.Client.Do does. That implementation changes the redirected requests to GET (from POST) requests though, and I bet this is why. This shouldn't affect our usage, since these requests don't have bodies anyway. What do you think the best way to deal with it is?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going to ask the same thing. Maybe we need a test with multiple redirects?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about this a bit more... If we decide to move to HTTP/2 for streaming requests, and if there are no changes to go's HTTP/2 library, then the only means we'll have of streaming data over the wire will be via the request and response bodies (we'd need to implementing muxing on top). Which would mean that when the client has data it wants to send to the server, the original request body needs to be preserved and available. I'm not sure how that would work... Also not sure it needs to stop this from going in.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we buffer a limited amount of the body and re-send it again and again? Like n<=1000 bytes. Every server should know what to do after those n bytes. If it asks for more, we continue to assume that there is no redirect.

@ncdc
Copy link
Member

ncdc commented Nov 1, 2016

I would feel more comfortable with something like this at the beginning of the dev cycle as opposed to just before code freeze. We may need to be prepared to revert and redo if things start behaving oddly.

Copy link
Author

@timstclair timstclair left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, addressed comments. Open question about handling request body in redirects.

I would feel more comfortable with something like this at the beginning of the dev cycle as opposed to just before code freeze. We may need to be prepared to revert and redo if things start behaving oddly.

Ack. There is a feature flag if need be, and this code path shouldn't be exercised normally anyway. Also, now is just before feature freeze, we still have a few more weeks of bug fixing, testing and stabilization.

if err != nil {
h.Responder.Error(err)
h.Responder.Error(fmt.Errorf("error dialing backend: %v", err))
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

return true
// Forward raw response bytes back to client.
if _, err = requestHijackedConn.Write(rawResponse); err != nil {
glog.Errorf("Error proxying response from backend to client: %v", err)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (What does this give us? Should I update the other error logging in the go routines below too?)

return nil, nil, fmt.Errorf("too many redirects (%d)", redirects)
}

intermediateConn, err = h.connectBackend(req, location)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think so, but this is also consistent with what http.Client.Do does. That implementation changes the redirected requests to GET (from POST) requests though, and I bet this is why. This shouldn't affect our usage, since these requests don't have bodies anyway. What do you think the best way to deal with it is?

if err != nil {
return nil, err
}
removeCORSHeaders(resp)
return resp, nil
}

var _ = net.RoundTripperWrapper(&corsRemovingTransport{})
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


conn, err = proxy.DialURL(location, h.Transport)
if err != nil {
return conn, fmt.Errorf("error dialing backend: %v", err)
Copy link
Member

@liggitt liggitt Nov 3, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return nil in error scenarios? otherwise we try to double close it, right? (in the defer here, and in the defer in connectBackendWithRedirects())

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Done.

defer func() {
if err != nil && conn != nil {
conn.Close()
conn = nil
Copy link
Member

@liggitt liggitt Nov 3, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assigning nil here doesn't change the returned value (https://play.golang.org/p/q5dnDAYsL6). actually do return nil, ... in error cases.

edit: I'm wrong, named returns make this work correctly. your call whether you want to switch it to clean up inline and return nil

@liggitt
Copy link
Member

liggitt commented Nov 3, 2016

this LGTM, go ahead and squash

we should keep the redirection length limitation issues in mind when continuing the CRI design...

@liggitt
Copy link
Member

liggitt commented Nov 3, 2016

hmmm

go test -v k8s.io/kubernetes/pkg/registry/generic/rest -run TestProxyUpgrade$
proxy_test.go:437: http with redirect: websocket dial err: websocket.Dial ws://127.0.0.1:60163/some/path: bad status

@k8s-ci-robot
Copy link
Contributor

Jenkins GCI GCE e2e failed for commit fa825d9. Full PR test history.

The magic incantation to run this job again is @k8s-bot gci gce e2e test this. Please help us cut down flakes by linking to an open flake issue when you hit one in your PR.

@timstclair
Copy link
Author

Oops, I forgot to flip the feature flag back on for the test.

@timstclair
Copy link
Author

Filed #36187 to track follow up work.

@timstclair
Copy link
Author

Squashed.

@k8s-ci-robot
Copy link
Contributor

Jenkins unit/integration failed for commit 389a54551c70a38611a4efbab6056ae7a950b23e. Full PR test history.

The magic incantation to run this job again is @k8s-bot unit test this. Please help us cut down flakes by linking to an open flake issue when you hit one in your PR.

@k8s-ci-robot
Copy link
Contributor

Jenkins verification failed for commit 389a54551c70a38611a4efbab6056ae7a950b23e. Full PR test history.

The magic incantation to run this job again is @k8s-bot verify test this. Please help us cut down flakes by linking to an open flake issue when you hit one in your PR.

@timstclair
Copy link
Author

@k8s-bot unit test this #32455

@timstclair
Copy link
Author

Reran hack/update-bazel.sh


redirectLoop:
for redirects := 0; ; redirects++ {
if redirects == maxRedirects {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Off by one. With maxRedirects==0 we should at least connect once.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

follow up for this is fine

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, fixed.

@liggitt liggitt added the lgtm "Looks good to me", indicates that a PR is ready to be merged. label Nov 4, 2016
@timstclair
Copy link
Author

Fixed off-by-one. Reapplying LGTM.

@timstclair timstclair added lgtm "Looks good to me", indicates that a PR is ready to be merged. and removed lgtm "Looks good to me", indicates that a PR is ready to be merged. labels Nov 4, 2016
@yujuhong yujuhong added the priority/backlog Higher priority than priority/awaiting-more-evidence. label Nov 5, 2016
@yujuhong
Copy link
Contributor

yujuhong commented Nov 5, 2016

Marking p2 to ensure PR dependency is respected.

@k8s-ci-robot
Copy link
Contributor

Jenkins GCE e2e failed for commit 6e0702a. Full PR test history.

The magic incantation to run this job again is @k8s-bot cvm gce e2e test this. Please help us cut down flakes by linking to an open flake issue when you hit one in your PR.

@timstclair
Copy link
Author

@k8s-bot cvm gce e2e test this #33380

@k8s-github-robot
Copy link

Automatic merge from submit-queue

@k8s-github-robot k8s-github-robot merged commit 7d1ef3e into kubernetes:master Nov 5, 2016
k8s-github-robot pushed a commit that referenced this pull request Nov 10, 2016
Automatic merge from submit-queue

Use indirect streaming path for remote CRI shim

Last step for #29579

- Wire through the remote indirect streaming methods in the docker remote shim
- Add the docker streaming server as a handler at `<node>:10250/cri/{exec,attach,portforward}`
- Disable legacy streaming for dockershim

Note: This requires PR #34987 to work.

Tested manually on an E2E cluster.

/cc @euank @feiskyer @kubernetes/sig-node
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
lgtm "Looks good to me", indicates that a PR is ready to be merged. priority/backlog Higher priority than priority/awaiting-more-evidence. release-note-none Denotes a PR that doesn't merit a release note. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

9 participants